iT邦幫忙

2023 iThome 鐵人賽

DAY 23
0
Modern Web

從 Next.js 開始的 Functional Programming系列 第 23

D23 - 實作異步流程 (九)

  • 分享至 

  • xImage
  •  

今天會介紹如何基於消費者驅動合約建立 Next.js 後端 API的測試,測試範圍包含步驟 3 到步驟 5。

程式碼請參考 D22/consumer-driven-contract

回顧一下我們昨天的合約

存在使用者 richard_00 到 richard_99 的狀態下

  1. 前端送出 GET /api/v1/users/richard_01 請求,會收到狀態碼 200 加上格式正確的使用者物件
  2. 前端送出 GET /api/v1/users/richard_x 請求,會收到狀態碼 404

在後端發生錯誤的情況下

  1. 前端送出 GET /api/v1/users/richard_01 請求,會收到狀態碼 500

根據 AAA 原則,第一步 Arange 要做的是製造 存在使用者 richard_00 到 richard_99 的狀態

準備測試資料

製造這 100 個 richard 很簡單,我們可以這樣做

const richard00To99: UserModel[] = pipe(
  RA.replicate(100)(''),
  RA.map((_, i) => `0${i}`.slice(-2)),
  RA.map((index) => ({
    name: `richard_${index}`,
    role: 'Administrator',
  }))
)

如果要測的更狠一點,也可以用前面講過的 Schema ,它有 Arbitraries 功能可以幫助我們生成隨機測試資料。

import { pipe } from "effect/Function";
import * as S from "@effect/schema/Schema";
import * as A from "@effect/schema/Arbitrary";
import * as fc from "fast-check";

const Person = S.struct({
  name: S.string,
  age: S.string.pipe(S.numberFromString, S.int())
});

// Arbitrary for the To type
const PersonArbitraryTo = A.to(Person)(fc);

console.log(fc.sample(PersonArbitraryTo, 2));
/*
[
  { name: 'WJh;`Jz', age: 3.4028216409684243e+38 },
  { name: 'x&~', age: 139480325657985020 }
]
*/

// Arbitrary for the From type
const PersonArbitraryFrom = A.from(Person)(fc);

console.log(fc.sample(PersonArbitraryFrom, 2));
/*
[ { name: 'Q}"H@aT', age: ']P$8w' }, { name: '|', age: '"' } ]
*/

來源

準備 Testcontainers

https://ithelp.ithome.com.tw/upload/images/20231008/20158615ai7okEh3Eh.png

顧名思義 Testcontainers 就是一個測試專用的、用過即丟的 container,使用它可以確保測試過程不會因為操作資料庫而影響到其他程式,或是影響測試結果。

接下來請參考以下步驟準備 testcontainer

  1. 安裝 yubin大大 包裝過的 testcontainer
    npm i -D testcontainers-mongoose
    
  2. 在測試開始前啟動 Testcontaner
    // src\app\api\v1\users\[username]\route.spec.ts
    
    let mongoTestContainer: StartedMongoTestContainer
    beforeAll(async () => {
      mongoTestContainer = await startedMongoTestContainerOf('mongo:7.0.0')
      const mongoUri = mongoTestContainer.getUri()
      // mongoUri 等等會使用到
    })
    
  3. 在每個測試結束的時候清空資料庫
    afterEach(async () => {
      await mongoTestContainer.clearDatabase()
    })
    
  4. 在所有測試結束的時候關閉 Testcontaner
    afterEach(async () => {
      await mongoTestContainer.clearDatabase()
    })
    

將測試資料注入 Testcontainer

Next.js 環境和一般專注於做後端服務的框架不同,直接使用會造成重複連線問題,所以我們需要客製化一個 mongoose singleton。

//src\plugins\mongoose-ex\connect.ts

import mongoose from 'mongoose'
declare global {
  var mongoose: any // This must be a `var` and not a `let / const`
}

let cached = global.mongoose

if (!cached) {
  cached = global.mongoose = { conn: null, promise: null }
}

const connect = async (uri: string) => {
  if (cached.conn) {
    return cached.conn
  }
  if (!cached.promise) {
    const opts = {
      bufferCommands: false,
    }
    cached.promise = mongoose.connect(uri, opts).then((mongoose) => {
      return mongoose
    })
  }
  try {
    cached.conn = await cached.promise
  } catch (e) {
    cached.promise = null
    throw e
  }

  return cached.conn
}

export default connect

來源

準備好 singleton 以後,我們就可以搶先在其他測試開始執行前,先把 mongoose 連線建立好,並且把先前準備好的測試資料 richard00To99 塞進去資料庫。

// src\app\api\v1\users\[username]\route.spec.ts

let mongoTestContainer: StartedMongoTestContainer
beforeAll(async () => {
  mongoTestContainer = await startedMongoTestContainerOf('mongo:7.0.0')
  const mongoUri = mongoTestContainer.getUri()
	await MongooseEx.connect(mongoUri)
  await UserModel.insertMany(richard00To99)
})

認識測試目標 Next.js Route Handler

開始寫測試前,我們先了解一下要測的目標。

//src\app\api\v1\users\[username]\route.ts

interface Route {
  params: { username: string }
}

export const GET = async (request: Request, route: Route): Promise<Response> => {
  throw Error('Todo')
}

它和 page.tsx 類似,可以從資料夾取得路徑參數。以上面例子來說, params: { username: string } 裡面的 username 必定會對應到請求路徑中 GET /api/v1/users/[username] 裡面的 [username]

除此之外比較特別的是它使用了瀏覽器 fetch API 中的 Request 和 Response 型別 ,但是我們實際執行環境卻是 Node.js,因此會導致 route handler 在單元測試環境出現問題 !

/**
 * @vitest-environment edge-runtime
 */

為了解決問題我們必須在測試檔案加上魔法字串,指定運行環境是 edge-runtime 來避免編譯錯誤

開始撰寫測試

  1. 再 ... 再 Arrange 一點針對這個測試的測資,這邊會參考到 Request API

      describe('when exists user richard_00 to richard_99', () => {
        it('should reply a user when username is richard_01', async () => {
          //arrange
          const username = 'richard_01'
          const request = new Request(`http://localhost/api/v1/users/${username}`)
          const params = { params: { username } }
    
        })
      })
    
  2. Act,把參數塞進去 route handler,完整模擬請求打進來時它被呼叫的樣子。

      describe('when exists user richard_00 to richard_99', () => {
        it('should reply a user when username is richard_01', async () => {
          //arrange
          ...
          //act
          const response = await GET(request, params)
        })
      })
    
  3. Assert,這邊會用到 Response API,並且使用 @effect/schemaparseSync,來直接把資料解開驗證

      describe('when exists user richard_00 to richard_99', () => {
        it('should reply a user when username is richard_01', async () => {
          //arrange
          ...
          //act
          ...
          //assert
          const status = response.status
          const rawData = await response.json()
          const data = S.parseSync(User.schema)(rawData)
          expect(status).toBe(200)
          expect(data.name).toBe(username)
        })
      })
    

    parseSync 如果出錯會直接拋出清楚的告訴你錯在哪的錯誤訊息。
    https://ithelp.ithome.com.tw/upload/images/20231008/20158615LFMlHCdzQU.png

以此類推,相信後面兩個測資大家也都會做了吧 (?

  • 前端送出 GET /api/v1/users/richard_x 請求,會收到狀態碼 404
  • 前端送出 GET /api/v1/users/richard_01 請求,會收到狀態碼 500

明天就來完成最後一步,實作 Next.js page router 並通過測試。


上一篇
D22 - 實作異步流程 (八)
下一篇
D24 - 實作異步流程 (十)
系列文
從 Next.js 開始的 Functional Programming30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言